这是「从零搭建 Agent」系列的第四篇。本章将在前三章构建的 Agent Loop、基础工具箱以及 Context Engine 的基础上,为 Agent 引入记忆系统(Memory System),通过分层记忆架构解决长链路任务中的 "遗忘" 问题。

当任务跨过很多轮、甚至跨过一个新会话时,Agent 应该怎样把重要事实留下来,又怎样在需要的时候把它找回来?

本章对应变更:https://github.com/Tritium0041/Singularity/commit/675e567bed52522ab2ed5385cec14fa1878d5f29 以及所有 stage3 相关

# 为什么做了 Context Engine 之后还需要 Memory 系统?

在前两章中,我们把模型的上下文通过 Context Engine 进行统一管理,分离了模型历史消息和每次请求用到的上下文信息,模型每一轮真正看到的是 Context Engine 处理后的 request view。但是,当任务变得足够长,上下文经历了多次压缩、整理后,某些曾经出现过的事实可能会从模型注意力中消失。这时,就需要一层 Memory 系统,让模型能够主动把关键信息写入非易失存储,并能在需要的时候进行读取和更改。

也就是说,记忆系统解决了这三个问题:

  • 哪些事实值得从短期上下文里提取出来?
  • 这些事实应该存在当前任务里,还是长期保存?(记忆分层)
  • 模型需要时,怎样把这些事实重新取回?

# 将模型的注意力分成三层

如果把所有事实都放进 memory,模型很快就会混乱。当前实现里,我把模型接触到的信息拆成三层。

第一层是当前上下文里的消息、工具调用、工具结果和压缩摘要。它由第二章的 Context Engine 管理。他们组成了 Agent 的短期记忆,目标是提供完成当前步骤所需的信息。

第二层是临时记忆,也就是当前任务生命周期内的 Workspace 笔记。它像一个任务草稿本,用来保存阶段性结论、重要文件、错误记录、待办项和设计决策。Workspace 中的信息不应该因为 history 被压缩就消失,能够在一个 session 中被稳定保存。

第三层是长期记忆,也就是跨任务、跨会话保存的 Memory Store。它适合保存用户偏好、项目约定、可复用经验和长期知识。比如「这个用户喜欢 TypeScript 示例」「这个 repo 的 markdown 成稿默认不纳入 git」「这个项目里 memory 内容必须 tool-first 召回」。

# 记忆工具的设计原则

同类工具的记忆系统中,可以抽出几个共同的设计原则。

第一个原则是读写分离。记忆读取和记忆写入不是一件事。读取发生在普通任务的各个阶段中,它应该轻量、可控、可解释;写入则意味着系统要决定什么值得长期保存,这件事更危险,也更容易污染记忆库。

第二个原则是记忆要有可解释性。在一次记忆召回中,检索为什么命中,返回给模型的具体内容是什么,都需要能够被明确归因,能让人理解模型为什么会这么想。

第三个原则是不要让记忆喧宾夺主。模型有 memory 不代表每一轮都应该看到所有 memory。太多被召回的旧偏好、旧经验、旧项目约定,会挤占短期上下文,也会把注意力从当前任务上拉走。

我们的实现中,也会遵守这几个原则,在保证克制和可解释性的基础上尽量提高记忆的稳定性。

# 记忆系统的整体实现

在这一章中,我们主要做了两组相关工作。

第一组是 Memory System,核心文件都在  src/memory/

src/memory/
  index.ts
  types.ts
  workspace.ts
  memory-store.ts
  memory-tools.ts
  instructions.ts
  phase-summary.ts

第二组是 demo 的多会话和恢复能力,核心文件在  src/session/ ,并接入到  examples/run-agent.ts

src/session/
  index.ts
  session-store.ts
  session-title.ts

这两组变更放在一起,是因为 Memory 不是一个孤立的工具。中期 Workspace 要跟随当前 Session 保存;长期记忆要独立于会话存在,同时 TUI 需要能展示当前 workspace notes、打开或关闭长期记忆、手动 compact 历史、创建新会话。

最终数据流变成了这样:

user input
  -> append user message to agent.history
  -> build runtime tool registry
     -> core tools
     -> dynamic compression tool if enabled
     -> memory tools if enabled
  -> build system prompt
     -> default instructions
     -> environment background
     -> static memory instructions
  -> ContextEngine.prepareWithHandoff()
     -> request view
  -> LLM
     -> answer or tool calls
  -> execute tools
     -> memory content returns as tool result when requested
  -> append assistant/tool messages to history
  -> after first completed request, enqueue background session title
  -> session store persists history and Workspace state

session 文件中保存的是当前会话历史、Workspace notes、usage telemetry 和标题。长期记忆默认存在  .agent-memory/MEMORY.md

# 中期记忆:Workspace Notes

Workspace 的类型实现很精简:

export const WORKSPACE_NOTE_KINDS = ["note", "decision", "file", "error", "todo"] as const;
export type WorkspaceNote = {
  id: string;
  kind: WorkspaceNoteKind;
  content: string;
  createdAt: string;
  updatedAt: string;
};
export type WorkspaceState = {
  notes: WorkspaceNote[];
};

这几个分类对应的是 Agent 在任务中最常需要留下来的东西:

  • note :一般事实。
  • decision :已经做出的设计选择。
  • file :重要文件或读过的入口。
  • error :关键错误和失败原因。
  • todo :后续要做的事情。

真正管理状态的是  WorkspaceMemory

export class WorkspaceMemory {
  private readonly notes: WorkspaceNote[];
  constructor(initial?: WorkspaceState) {
    this.notes = (initial?.notes ?? []).map(cloneNote);
    for (const note of this.notes) {
      validateKind(note.kind);
      if (note.content.trim() === "") {
        throw new Error("Workspace note content must be non-empty.");
      }
    }
  }
  get state(): WorkspaceState {
    return {
      notes: this.notes.map(cloneNote)
    };
  }
  write(input: { kind?: WorkspaceNoteKind; content: string }): WorkspaceNote;
  read(filter?: { id?: string; kind?: WorkspaceNoteKind }): WorkspaceNote[];
  update(input: { id: string; kind?: WorkspaceNoteKind; content?: string }): WorkspaceNote;
  delete(id: string): boolean;
  clear(): void;
}

这里有几个看似普通但很重要的细节:

第一, content  会 trim,并且不能为空。记忆系统最怕被空 note、半截 note 和无意义 note 污染。

第二, state  返回的是 clone,不允许外部直接改内部数组。Workspace 是 Agent 的运行状态,不能让调用方拿到引用后绕过校验。

对应的工具会被动态写入系统 instruction 中。 write_note  用来写入当前任务的重要状态, read_note  可以按  id  或  kind  读取, update_workspace  既可以更新 note,也可以删除 note,带一个  delete: true  就是删除。同时,Agent 在任何时候可以调用 list_notes 工具来列出当前所有 notes,并查看他们的开头前 80 个字符。

# 长期记忆:Markdown Memory Store

长期记忆关注的是跨任务、跨会话中,用户始终要求遵循的指示。

参照 Codex,我们实现了一个  MarkdownMemoryStore 。默认存储的记忆路径是:

.agent-memory/MEMORY.md

初始化后的文件长这样:

# Singularity Memory
Long-term memory for this local agent. Entries are append-only Markdown blocks.

每条记忆是一个 Markdown 二级标题块:

## mem_20260613_201530_ab12
- tags: typescript, preference
- source: user
- created_at: 2026-06-13T20:15:30.000Z
- updated_at: 2026-06-13T20:15:30.000Z
User prefers TypeScript for scripts.

用 Markdown 格式的好处是能做非常轻量化的实现,解析逻辑简单,不需要数据库迁移。Agent 也可以轻松调用搜索工具,不需要额外负担就能实现记忆召回

MarkdownMemoryStore  暴露了几类方法:

store(input): Promise<MemoryEntry>
list(options): Promise<MemoryEntry[]>
search(query, options): Promise<MemorySearchResult[]>
clear(): Promise<void>
update(input): Promise<MemoryEntry>
upsertByTag(input): Promise<{ entry: MemoryEntry; created: boolean }>

store()  是最基础的写入。它会规范化内容、tags 和 source,然后 append 一个新的 memory block。

list()  可以列出全部条目,也可以按 tag 过滤。

search()  做的是轻量级关键词检索,而不是 embedding 检索。它会:

  1. 对 query 做小写和分词。
  2. 先按 tag 过滤可选范围。
  3. 给整句命中、tag 命中、term 命中不同权重(整句 6 分、tag 精确 4 分、tag 包含 2 分、term 命中 1 分、tag 过滤额外 2 分)。
  4. 按 score 和更新时间排序。
  5. 返回 snippet。

snippet 会优先从正文里截取命中片段,而不是从 metadata 里截取。否则搜索  typescript  时,很可能只看到  - tags: typescript ,看不到真正的记忆内容。

对于模型层,我们提供了两个对长期记忆的原子操作,专注于存储和召回(store_memory 和 search_memory)。

store_memory  的描述很严格:

Store durable long-term memory as a local Markdown entry.
Only save user preferences, project conventions, or reusable lessons.
Never store secrets, credentials, or one-off temporary facts.

这句话其实就是长期记忆的边界,它不应该保存:

  • 一次性任务细节。
  • 临时搜索结果。
  • 密钥、token、cookie。
  • 可以从当前 repo 重新读出来的普通文件内容。

它应该保存的是未来任务也会用到的东西:

  • 用户偏好。
  • 项目约定。
  • 反复踩坑后的经验。
  • 某个 repo 的稳定操作方式。

search_memory  则是读取入口:

{
  "query": "TypeScript script preference",
  "tags": ["preference"],
  "maxResults": 3
}

返回结果会把 entry 的核心字段都带回来:

{
  "results": [
    {
      "id": "mem_...",
      "content": "User prefers TypeScript for scripts.",
      "tags": ["typescript", "preference"],
      "source": "user",
      "score": 8,
      "matchedTerms": ["typescript"],
      "snippet": "User prefers TypeScript for scripts."
    }
  ]
}

# 静态 Memory Instructions

只有工具还不够,模型需要知道什么时候应该使用这些工具。

所以 Memory System 加了一个稳定 prompt fragment:

export function buildMemoryInstructions(options: { hasWorkspace: boolean; hasStore: boolean }): PromptFragment | undefined {
  const lines = ["Memory tools are available. Use them proactively when they can materially improve the answer:"];
  if (options.hasStore) {
    lines.push("- Use search_memory before relying on remembered user preferences, project conventions, or prior solutions.");
  }
  if (options.hasWorkspace) {
     lines.push("- Use list_notes to inspect current task workspace notes before reading full note content.");
    lines.push("- Use read_note when you need the full content of specific workspace notes.");
    lines.push("- Use write_note for important current-task state that should survive context compaction.");
  }
  if (options.hasStore) {
    lines.push("- Use store_memory only for durable preferences, project conventions, or reusable lessons.");
    lines.push("- Never store secrets or one-off temporary facts.");
  }
  lines.push("- Do not assume memory exists. Treat only tool results as retrieved memory evidence.");
  return {
    id: "memory-instructions",
    stable: true,
    content: lines.join("\n")
  };
}

注意最后一句 Do not assume memory exists. Treat only tool results as retrieved memory evidence.

它是整个 v1 的核心约束。模型不能因为 system prompt 里说「memory tools are available」就假装自己已经知道长期记忆。它必须调用工具,拿到 tool result,然后才能把结果当证据用。

# Memory 和 Context Engine 到底是什么关系?

现在可以回到本章最关键的工程问题:Memory 和 Context Engine 到底是什么关系?

在我们的实现中,Memory 只会作为一组特定的工具调用结果回到模型上下文中。这一设计的好处是尽量克制地控制了模型的注意力,仅有特定需要记忆中事实的时刻,才会召回记忆点。同时,这也让记忆和其他上下文一样,受到 Context Engine 的管理。记忆内容一旦以 tool result 进入 agent.history,它就和 read_file、execute_command、fetch_url 的结果一样,接受 Context Engine 的工具结果截断、token 估算、history compaction 和 dynamic compression。

# Side work:添加了多会话,让记忆有用武之地

在这次变更中,我们同时加了多 session 的支持。session 会把消息记录保存到本地 json 状态文件里,Agent 可以根据状态文件随时恢复到任意 Session 中。

session store 负责:

  • 保存每个 TUI 会话的 messages。
  • 保存 WorkspaceState。
  • 保存 exchangeCount。
  • 保存 usage telemetry。
  • 维护 active session index。
  • 支持 session list、switch、rename、delete。

每个新会话在第一次完成请求后,demo 还会在后台调用 summary model 生成一个短标题:

[session:title] model=... messages=...
[session:title] "Memory System Article"; tokens=8 chars=20 ...

这里的判断是  exchangeCount === 0 且标题还是默认的 "Untitled session" ,也就是只有「新会话的第一轮」才会触发。它复用的是  AGENT_SUMMARY_PROVIDER  /  AGENT_SUMMARY_MODEL  配置的 summary model,作为后台任务运行,不阻塞下一条用户输入。

标题生成的 system prompt 很克制:要求用用户的语言、3 到 8 个词、去掉引号和 Markdown、只返回名字。 normalizeSessionTitle()  还会做一层清洗,去掉前导的  title: 、列表符号、首尾引号和尾部标点,并截断到 80 字符。这个标题能力目的只是让本地会话列表更容易浏览,不会写入长期记忆。

这里可以看到三种持久化的区别:

agent.history
  -> 当前 Agent 实例里的完整对话历史
.agent-sessions/
  -> TUI 会话历史 + Workspace notes + usage
.agent-memory/MEMORY.md
  -> 跨会话长期记忆

# 回到 Harness:记忆不是一个魔法 prompt

到这里,Singularity 的 Harness 已经有两块核心能力。

第一块是 Context Engine。它负责短期上下文管理:

tool result truncation
token estimate
budget decision
handoff summary
dynamic compression
request view

第二块是 Memory System。它负责可持久、可召回的状态:

Workspace notes
Markdown long-term memory
memory tools
static memory instructions
session persistence

这两块合起来,Agent 的能力边界发生了变化。

第二章的 Agent 只能「沿着对话历史往前走」。历史里有什么,它就能看到什么;历史太长,它就开始吃力。

第三章的 Agent 能「整理自己当前能看到的东西」。它可以压缩旧历史、保留最近上下文、把长工具结果截断。

第四章之后,Agent 开始能「主动维护状态」。它可以把当前任务的重要事实写入 Workspace,也可以把长期偏好和经验写入 Markdown Store;需要时再通过工具把它们取回。

但这套记忆能力不是魔法 prompt。它更像一组外部状态接口:

I might need memory.
  -> call search_memory/read_note
  -> inspect tool result
  -> use retrieved evidence
  -> continue the task

这也是我认为 coding agent 里最健康的 memory 形态:不要让模型假装自己永远记得一切,而是给它一个可靠的方式,在需要的时候去查。

# 小结

在这一章中,我们新增了  WorkspaceMemory  作为当前任务的中期记忆;新增了  MarkdownMemoryStore  作为本地长期记忆;新增了  write_noteread_noteupdate_workspacestore_memorysearch_memory  五个 memory tools;把静态 memory instructions 接进 PromptBuilder;把 memory tools 接进 Agent runtime registry;又在 demo 里加入了  /memory/notes/forget-notes 、多 session 持久化和后台标题生成。

下一章中,我们会继续实现 Harness 的第三块能力:Planner。有了 Context Engine 和 Memory System 之后,Agent 已经能管理「看什么」和「记什么」。下一步要解决的是「先做什么、后做什么、做到哪里算完成」。也就是任务分解、任务树、计划更新和 Plan-and-Solve。到那一步,Agent 就不只是能循环调用工具,也不只是能记住状态,开始有一个可以持续修正的行动结构,在达到目标之前不会停止行动。